Skip to content

feat: Tier 0 server auth - cookie lifecycle, CSRF enforcement, device tokens, SSO wiring#1004

Open
pyramation wants to merge 4 commits intomainfrom
devin/1776502080-tier0-cookie-csrf-sso
Open

feat: Tier 0 server auth - cookie lifecycle, CSRF enforcement, device tokens, SSO wiring#1004
pyramation wants to merge 4 commits intomainfrom
devin/1776502080-tier0-cookie-csrf-sso

Conversation

@pyramation
Copy link
Copy Markdown
Contributor

@pyramation pyramation commented Apr 18, 2026

Summary

Adds the server-side foundation for cookie-based authentication as an opt-in layer alongside existing Bearer token auth. All features are gated behind app_auth_settings toggles (enable_cookie_auth, require_csrf_for_auth) and are off by default — zero behavior changes for existing API token flows.

New middleware files:

  • cookie.ts — Intercepts auth mutation responses (signIn, signUp, completeMfaChallenge, etc.) to set/clear an HttpOnly constructive_session cookie from the returned access_token. Also sets a long-lived constructive_device_token cookie when device tracking returns a device_id. Only active when enable_cookie_auth = true. Uses res.writeHead()/res.end() interception (not res.json()) because grafserv bypasses Express response helpers — see details below.

  • csrf.ts — Wires the existing @constructive-io/csrf package into the middleware chain. CSRF validation is only enforced on cookie-authenticated requests (req.tokenSource === 'cookie'). Bearer token requests skip CSRF entirely since they're not vulnerable to CSRF attacks.

  • oauth.ts — Mounts /auth/:provider, /auth/:provider/callback, and /auth/providers routes. On successful OAuth callback, calls sign_in_sso() on the tenant DB private schema and optionally sets the session cookie.

  • captcha.ts (modified) — Now accepts ConstructiveOptions and reads opts.captcha.recaptchaSecretKey instead of process.env.RECAPTCHA_SECRET_KEY.

Other changes:

  • AuthSettings interface and AUTH_SETTINGS_SQL expanded with enableCookieAuth and requireCsrfForAuth
  • req.tokenSource ('bearer' | 'cookie' | 'none') set in auth middleware for downstream use
  • cookie-parser, @constructive-io/csrf, @constructive-io/oauth added as server dependencies
  • Middleware wired into server.ts in the correct order: cookieParser → api → OAuth routes → authenticate → CSRF setToken → CSRF protect → captcha → admin flush → cookie lifecycle → graphile

Updates since last revision

Admin cache flush endpoint (POST /admin/flush-auth-settings):

Added a secure admin endpoint for manually flushing cached app_auth_settings from the server's svcCache. This is needed because auth settings are cached per-tenant in svcCache/graphileCache, and in a multi-tenant setup, pg_notify from the tenant DB won't reach the server's LISTEN on the services DB (cross-database limitation).

The endpoint requires two gates:

  1. Administrator roletoken.role === 'administrator' (database-level superuser, not org-level)
  2. Step-up authenticationtoken.access_level === 'password_or_mfa'

On success, it deletes both svcCache and graphileCache entries for the current tenant's svc_key, so the next request re-queries auth settings from the tenant DB.

Env var convention refactoring

All new middleware follows the workspace convention of zero direct process.env access:

  • New types: OAuthOptions, OAuthProviderCredentials, CaptchaOptions added to ConstructiveOptions (in graphql/types)
  • Centralized parsing: OAUTH_* (12 env vars) and RECAPTCHA_SECRET_KEY are now parsed in getGraphQLEnvVars() (graphql/env/src/env.ts) — same pattern as SMTP_*, API_*, etc.
  • getNodeEnv(): All process.env.NODE_ENV checks in cookie.ts, csrf.ts, oauth.ts, captcha.ts, and server.ts replaced with getNodeEnv() from @pgpmjs/env
  • Config via opts: oauth.ts reads providers/baseUrl/redirects from opts.oauth.*; captcha.ts reads the secret key from opts.captcha.recaptchaSecretKey
  • Result: Zero process.env references in any new middleware file

grafserv response interception

Research into grafserv (PostGraphile v5) source confirmed it never calls res.json(). Instead, it writes responses directly via:

// grafserv/dist/servers/node/index.js, "json" case (~line 105)
const buffer = Buffer.from(JSON.stringify(json), "utf8");
headers["Content-Length"] = String(buffer.length);
res.writeHead(statusCode, headers);
res.end(buffer);

The cookie middleware therefore:

  1. Defers res.writeHead() — captures statusCode and headers without sending them
  2. Intercepts res.end() — parses the JSON body from the buffer, extracts accessToken/deviceId, builds Set-Cookie header values manually via serializeCookie()/serializeClearCookie() helpers
  3. Merges cookie headers into the deferred headers, then flushes writeHead + end together

Review & Testing Checklist for Human

  • Admin flush endpoint auth gates: The flushAuthSettingsCache handler checks token.role !== 'administrator' and token.access_level !== 'password_or_mfa' via string comparison. Verify these match the exact values the authenticate function sets — if the role or access level uses different casing or naming, the check silently fails open (returns 403, which is safe, but won't work for legitimate admins either).
  • writeHead/end deferral in cookie.ts (~lines 252–330): If any grafserv code path calls res.end() without first calling res.writeHead(), deferredStatusCode will be undefined and the interceptor will skip writeHead entirely, falling through to originalEnd — this should be safe (Node.js auto-sends headers), but verify no edge cases produce malformed responses. Also confirm Content-Length (set by grafserv based on body buffer size) remains accurate after Set-Cookie headers are injected into the headers object (it should, since Content-Length refers to the body, not headers).
  • Operation name extraction: Cookie middleware reads req.body.operationName to identify auth mutations. This requires the body to be parsed before this middleware runs, and the client must send operationName in the request. Unnamed mutations will silently skip cookie-setting — confirm this is acceptable or if a fallback (AST parsing) is needed.
  • OAuth email_verified hardcoded to false (oauth.ts line 80): The SSO flow always passes false for email_verified to sign_in_sso(). Providers like Google do return verified email status — confirm whether this conservative default is acceptable or if provider-specific logic is needed.
  • OAuth WeakMap<object, Request> pattern (oauth.ts ~line 148): The request is stashed keyed on req.query to pass it into the onSuccess callback. If the OAuth package reconstructs the query object rather than preserving the same reference, the lookup will silently fail and sign_in_sso() won't be called. Verify with an actual OAuth flow.

Recommended test plan: Enable enable_cookie_auth and require_csrf_for_auth in a tenant's app_auth_settings, then:

  1. Call signIn mutation with Bearer token → verify no cookie is set, CSRF is not enforced
  2. Call signIn mutation without Bearer token → verify constructive_session cookie is set in response
  3. Call a mutation with only the session cookie → verify CSRF enforcement kicks in (403 without token)
  4. Call signOut → verify session cookie is cleared
  5. Verify existing Bearer token flows are completely unchanged
  6. Admin flush: As an admin with step-up auth, POST /admin/flush-auth-settings → verify 200 response. Repeat without step-up auth → verify 403. Repeat as non-admin → verify 403.

Notes

  • The lockfile diff is large but is mostly YAML formatting changes (multi-line → single-line resolution fields) plus the 3 new dependencies (cookie-parser, @types/cookie-parser, and workspace links for csrf/oauth).
  • No unit tests are included for the new middleware files. Consider adding tests for parseIntervalToMs, serializeCookie/serializeClearCookie, the cookie extraction helpers, the CSRF skip logic, and the admin flush auth gates.
  • The OAuth middleware uses a WeakMap<object, Request> keyed on req.query to pass the Express request into the onSuccess callback — this is a workaround for the OAuth package's callback API not exposing the raw request.
  • Pre-existing process.env usage in upload.ts (MAX_UPLOAD_FILE_SIZE) and graphile.ts (NODE_ENV for explain) are out of scope for this PR.
  • The admin flush endpoint only clears the svc_key-based cache entry for the requesting tenant — it does not flush all tenants. For a full cross-tenant flush, the existing schema:update NOTIFY mechanism (triggered by metaschema_public.database.hash changes) remains the correct approach.
  • The admin flush endpoint sits after CSRF middleware in the chain, so cookie-authenticated admin requests must also pass CSRF validation — this is intentional and correct.

Link to Devin session: https://app.devin.ai/sessions/12acfda2a5434d2686c63515cfeb2610
Requested by: @pyramation

… tokens, SSO wiring

- Add cookie lifecycle middleware (cookie.ts): intercepts auth mutation
  responses and sets/clears HttpOnly session cookies when enable_cookie_auth
  is true in app_auth_settings. Handles all sign-in/sign-up/sign-out mutations.

- Add CSRF protection middleware (csrf.ts): wires @constructive-io/csrf
  package into server. Only enforces on cookie-authenticated requests,
  completely skips Bearer token requests. Controlled by require_csrf_for_auth
  toggle in app_auth_settings.

- Add device token cookie support: on sign-in responses that include a
  device_id, sets a long-lived (90 day) constructive_device_token cookie
  for trusted device tracking.

- Add SSO/OAuth route middleware (oauth.ts): mounts /auth/:provider and
  /auth/:provider/callback routes. On successful OAuth callback, calls
  sign_in_sso() on the tenant DB private schema and optionally sets
  session cookie when cookie auth is enabled.

- Expand AuthSettings interface with enableCookieAuth and requireCsrfForAuth
  toggles. Update AUTH_SETTINGS_SQL query to fetch these columns.

- Set req.tokenSource on authenticated requests so downstream middleware
  knows whether auth came from bearer header, cookie, or none.

- Add cookie-parser, @constructive-io/csrf, @constructive-io/oauth as
  server dependencies.

Backward compatibility:
- All features are opt-in via app_auth_settings toggles (default: off)
- Bearer token authentication continues to work exactly as before
- CSRF only enforces on cookie-authenticated requests
- No changes to existing GraphQL mutations or API contracts
@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

…okie injection

grafserv (PostGraphile v5) writes responses via res.writeHead() + res.end(buffer),
completely bypassing Express's res.json(). The previous res.json monkey-patch would
never fire for GraphQL responses.

New approach: defer res.writeHead(), intercept res.end() to parse the JSON body,
compute Set-Cookie headers, merge them into the deferred headers, then flush
writeHead + end together. Also adds serializeCookie/serializeClearCookie helpers
since we can't rely on Express's res.cookie() when headers are managed manually.
…NodeEnv()

- Add OAuthOptions and CaptchaOptions to ConstructiveOptions type
- Add OAUTH_* and RECAPTCHA_SECRET_KEY parsing to getGraphQLEnvVars()
- Replace process.env.NODE_ENV with getNodeEnv() in cookie.ts, csrf.ts, oauth.ts, server.ts
- Refactor oauth.ts to read providers/baseUrl/redirects from opts.oauth
- Refactor captcha.ts to read recaptchaSecretKey from opts.captcha
- Pass effectiveOpts to createCaptchaMiddleware() in server.ts
- Zero process.env references remain in new middleware files
Admin-only endpoint to flush cached auth settings for the current tenant.
Requires administrator role + password_or_mfa access level (step-up auth).
Clears both svcCache and graphileCache entries so the next request
re-queries auth settings from the tenant DB.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant